package tech.easyflow.agent.runtime;

import com.easyagents.agent.runtime.AgentInitRequest;
import com.easyagents.agent.runtime.AgentRuntime;
import com.easyagents.agent.runtime.event.AgentRuntimeEvent;
import com.easyagents.agent.runtime.event.AgentRuntimeEventType;
import com.easyagents.agent.runtime.message.AgentKnowledgeReference;
import com.easyagents.agent.runtime.message.AgentMessage;
import com.easyagents.agent.runtime.message.AgentMessageRole;
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.Mockito;
import tech.easyflow.agent.entity.AgentHitlPending;
import tech.easyflow.agent.entity.Agent;
import tech.easyflow.agent.entity.AgentKnowledgeBinding;
import tech.easyflow.agent.entity.AgentToolBinding;
import tech.easyflow.agent.distributed.AgentRuntimeCommandProducer;
import tech.easyflow.agent.distributed.AgentRuntimeRoute;
import tech.easyflow.agent.distributed.AgentRuntimeRouteRegistry;
import tech.easyflow.agent.runtime.event.AgentRunEventRecorder;
import tech.easyflow.agent.runtime.hitl.AgentHitlPendingService;
import tech.easyflow.agent.runtime.lock.AgentRunLock;
import tech.easyflow.agent.runtime.media.AgentBoundMedia;
import tech.easyflow.agent.runtime.media.AgentMediaService;
import tech.easyflow.chatlog.domain.dto.ChatSessionSummary;
import tech.easyflow.common.entity.LoginAccount;
import tech.easyflow.common.web.exceptions.BusinessException;
import tech.easyflow.core.chat.protocol.ChatDomain;
import tech.easyflow.core.chat.protocol.ChatEnvelope;
import tech.easyflow.core.chat.protocol.ChatType;
import tech.easyflow.core.chat.protocol.sse.ChatSseEmitter;
import tech.easyflow.core.runtime.ChatAssistantAccumulator;
import tech.easyflow.core.runtime.ChatRuntimeContext;
import tech.easyflow.core.runtime.ChatRuntimeManager;
import tech.easyflow.core.runtime.ChatRuntimeMessage;
import tech.easyflow.core.runtime.LegacyThinkingTagParser;

import java.lang.reflect.Method;
import java.math.BigInteger;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicBoolean;

/**
 * Agent 草稿试用与 HITL 事件映射测试。
 */
public class AgentRunServiceDraftAndHitlTest {

    /**
     * 验证工具 HITL 事件会映射为显式前端载荷。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void buildToolHitlPayloadShouldExposeStableFieldsWithoutPrompt() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
        event.setToolCallId("call-1");
        event.getPayload().put("resumeToken", "token-1");
        event.getPayload().put("sessionId", "session-1");
        event.getPayload().put("agentId", "agent-1");
        event.getPayload().put("toolName", "search");
        event.getPayload().put("toolType", "PLUGIN");
        event.getPayload().put("approvalPrompt", "不应透出");
        event.getPayload().put("toolInput", Map.of("keyword", "EasyFlow"));
        event.getPayload().put("approvalMetadata", Map.of(
                "risk", "low",
                "prompt", "不应透出",
                "toolType", "WORKFLOW"
        ));

        AgentToolHitlPayload payload = invoke(service, "buildToolHitlPayload",
                new Class<?>[]{String.class, AgentRuntimeEvent.class}, "request-1", event);

        Assert.assertEquals("request-1", payload.getRequestId());
        Assert.assertEquals("token-1", payload.getResumeToken());
        Assert.assertEquals("session-1", payload.getSessionId());
        Assert.assertEquals("agent-1", payload.getAgentId());
        Assert.assertEquals("call-1", payload.getToolCallId());
        Assert.assertEquals("search", payload.getToolName());
        Assert.assertEquals("PLUGIN", payload.getToolType());
        Assert.assertEquals("EasyFlow", payload.getInput().get("keyword"));
        Assert.assertEquals("low", payload.getMetadata().get("risk"));
        Assert.assertEquals("PLUGIN", payload.getMetadata().get("toolType"));
        Assert.assertFalse(payload.getMetadata().containsKey("prompt"));
    }

    /**
     * 验证工具事件发送给前端时会携带稳定工具调用 ID。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void buildToolEventPayloadShouldExposeRuntimeToolCallId() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_RESULT);
        event.setToolCallId("call-runtime");
        event.getPayload().put("toolName", "search");
        event.getPayload().put("text", "ok");

        Map<String, Object> payload = invoke(service, "buildToolEventPayload",
                new Class<?>[]{AgentRuntimeEvent.class}, event);

        Assert.assertEquals("call-runtime", payload.get("toolCallId"));
        Assert.assertEquals("search", payload.get("toolName"));
        Assert.assertEquals("ok", payload.get("text"));
    }

    /**
     * 验证思考事件会优先读取 reasoning 字段。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void stringPayloadShouldExposeReasoningValue() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.REASONING_DELTA);
        event.getPayload().put("reasoning", "思考中");

        String reasoning = invoke(service, "stringPayload",
                new Class<?>[]{AgentRuntimeEvent.class, String.class}, event, "reasoning");
        String fallback = invoke(service, "firstText",
                new Class<?>[]{String.class, String.class}, reasoning, "正文");

        Assert.assertEquals("思考中", fallback);
    }

    /**
     * 验证思考事件会作为增量发送给前端。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldSendReasoningDeltaAsThinkingEnvelope() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.REASONING_DELTA);
        event.getPayload().put("reasoning", "思考增量");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
                chatContext(), new AtomicBoolean(false), false);

        Assert.assertEquals(1, emitter.envelopes.size());
        Assert.assertEquals(ChatDomain.LLM, emitter.envelopes.get(0).getDomain());
        Assert.assertEquals(ChatType.THINKING, emitter.envelopes.get(0).getType());
        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
        Assert.assertEquals("思考增量", payload.get("reasoning"));
        Assert.assertEquals("思考增量", payload.get("delta"));
    }

    /**
     * 验证正文事件会作为增量发送给前端并累计到持久化缓冲。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldSendMessageDeltaAndAccumulateAnswer() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        StringBuilder answer = new StringBuilder();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
        event.getPayload().put("text", "正文增量");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
                chatContext(), new AtomicBoolean(false), false);

        Assert.assertEquals("正文增量", answer.toString());
        Assert.assertEquals(1, emitter.envelopes.size());
        Assert.assertEquals(ChatDomain.LLM, emitter.envelopes.get(0).getDomain());
        Assert.assertEquals(ChatType.MESSAGE, emitter.envelopes.get(0).getType());
        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
        Assert.assertEquals("正文增量", payload.get("delta"));
    }

    /**
     * 验证旧模型写入 content 的思考标签即使跨增量拆分，也会转换为结构化思考事件。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldSplitLegacyThinkingTagsAcrossDeltas() throws Exception {
        AgentRunService service = new AgentRunService();
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        StringBuilder answer = new StringBuilder();
        ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();
        LegacyThinkingTagParser parser = new LegacyThinkingTagParser();
        AtomicBoolean finished = new AtomicBoolean(false);

        for (String delta : List.of("<thi", "nk>先分析</thi", "nk>\n最终回答")) {
            AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
            event.getPayload().put("text", delta);
            invoke(service, "handleRuntimeEvent",
                    legacyRuntimeEventParameterTypes(),
                    event, "request-legacy-thinking", emitter, answer, assistantAccumulator,
                    parser, chatContext(), finished, false);
        }

        AgentRuntimeEvent completed = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
        completed.getPayload().put("text", "<think>先分析</think>\n最终回答");
        invoke(service, "handleRuntimeEvent",
                legacyRuntimeEventParameterTypes(),
                completed, "request-legacy-thinking", emitter, answer, assistantAccumulator,
                parser, chatContext(), finished, false);

        StringBuilder reasoning = new StringBuilder();
        StringBuilder content = new StringBuilder();
        for (ChatEnvelope<?> envelope : emitter.envelopes) {
            if (envelope.getDomain() != ChatDomain.LLM) {
                continue;
            }
            @SuppressWarnings("unchecked")
            Map<String, Object> payload = (Map<String, Object>) envelope.getPayload();
            if (envelope.getType() == ChatType.THINKING) {
                reasoning.append(payload.get("delta"));
            } else if (envelope.getType() == ChatType.MESSAGE) {
                content.append(payload.get("delta"));
            }
        }

        Assert.assertEquals("先分析", reasoning.toString());
        Assert.assertEquals("\n最终回答", content.toString());
        Assert.assertEquals("\n最终回答", answer.toString());
        Assert.assertTrue(emitter.envelopes.stream().anyMatch(envelope ->
                envelope.getDomain() == ChatDomain.SYSTEM && envelope.getType() == ChatType.DONE));
    }

    /**
     * 验证自动上下文压缩事件会作为业务状态发送给前端。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldSendMemoryCompressionAsStatusEnvelope() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MEMORY_COMPRESSION_STARTED);
        event.getPayload().put("statusKey", "memory-compression");
        event.getPayload().put("status", "running");
        event.getPayload().put("label", "正在整理上下文");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-1", emitter, new StringBuilder(), new ChatAssistantAccumulator(),
                chatContext(), new AtomicBoolean(false), false);

        Assert.assertEquals(1, emitter.envelopes.size());
        Assert.assertEquals(ChatDomain.BUSINESS, emitter.envelopes.get(0).getDomain());
        Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
        Assert.assertEquals("memory-compression", payload.get("statusKey"));
        Assert.assertEquals("running", payload.get("status"));
        Assert.assertEquals("正在整理上下文", payload.get("label"));
    }

    /**
     * 验证完成事件不会再次发送正文消息，只用于最终收口。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldNotSendMessageEnvelopeOnCompleted() throws Exception {
        AgentRunService service = new AgentRunService();
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        StringBuilder answer = new StringBuilder("流式正文");
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
        event.getPayload().put("text", "最终正文");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
                chatContext(), new AtomicBoolean(false), false);

        Assert.assertEquals("最终正文", answer.toString());
        Assert.assertTrue(emitter.envelopes.stream().noneMatch(envelope ->
                envelope.getDomain() == ChatDomain.LLM && envelope.getType() == ChatType.MESSAGE));
        Assert.assertTrue(emitter.envelopes.stream().anyMatch(envelope ->
                envelope.getDomain() == ChatDomain.SYSTEM && envelope.getType() == ChatType.DONE));
    }

    /**
     * 验证挂起事件后自然完成不会关闭 SSE 或清理运行态。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void finishIfNeededShouldKeepRuntimeWhenSuspended() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRunRegistry registry = new AgentRunRegistry();
        setField(service, "agentRunRegistry", registry);
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        AtomicBoolean finished = new AtomicBoolean(false);
        AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
                "request-suspended",
                "session-suspended",
                new NoopRuntime(),
                emitter,
                chatContext(),
                new StringBuilder(),
                new ChatAssistantAccumulator(),
                finished,
                false,
                new AgentRunRegistry.RunOwner("agent-1", "session-suspended", "user-1"),
                null,
                event -> {
                },
                error -> {
                },
                () -> {
                }
        );
        registry.register(runContext);
        runContext.markSuspended();

        invoke(service, "finishIfNeeded",
                new Class<?>[]{String.class, ChatSseEmitter.class, ChatRuntimeContext.class, StringBuilder.class,
                        ChatAssistantAccumulator.class, AtomicBoolean.class, boolean.class},
                "request-suspended", emitter, chatContext(), new StringBuilder(),
                new ChatAssistantAccumulator(), finished, false);

        Assert.assertFalse(finished.get());
        Assert.assertNotNull(registry.get("request-suspended"));
        Assert.assertTrue(emitter.envelopes.isEmpty());
    }

    /**
     * 验证取消事件作为业务状态收口，不按系统错误发送。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldSendCancelledAsStatusAndDone() throws Exception {
        AgentRunService service = new AgentRunService();
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
        setField(service, "chatRuntimeManager", chatRuntimeManager);
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        StringBuilder answer = new StringBuilder("取消前正文");
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.CANCELLED);
        event.getPayload().put("reason", "用户拒绝执行");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-1", emitter, answer, new ChatAssistantAccumulator(),
                chatContext(), new AtomicBoolean(false), true);

        Assert.assertEquals(2, emitter.envelopes.size());
        Assert.assertEquals(ChatDomain.BUSINESS, emitter.envelopes.get(0).getDomain());
        Assert.assertEquals(ChatType.STATUS, emitter.envelopes.get(0).getType());
        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
        Assert.assertEquals("agent-cancelled", payload.get("statusKey"));
        Assert.assertEquals("cancelled", payload.get("status"));
        Assert.assertEquals("用户拒绝执行", payload.get("message"));
        Assert.assertEquals(ChatDomain.SYSTEM, emitter.envelopes.get(1).getDomain());
        Assert.assertEquals(ChatType.DONE, emitter.envelopes.get(1).getType());
        Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
        Assert.assertEquals("取消前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
        Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
    }

    /**
     * 验证最终知识库引用会保留命中分片原文。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void buildKnowledgeCitationPayloadShouldExposeChunkContent() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.COMPLETED);
        AgentMessage message = AgentMessage.text(AgentMessageRole.ASSISTANT, "answer");
        AgentKnowledgeReference reference = new AgentKnowledgeReference();
        reference.setKnowledgeId("kb-1");
        reference.setKnowledgeName("学生事务 FAQ");
        reference.setDocumentId("faq-1");
        reference.setDocumentName("faq-1.md");
        reference.setChunkId("chunk-1");
        reference.setChunkContent("暑假安排原文");
        reference.setScore(0.91D);
        reference.getMetadata().put("knowledgeType", "FAQ");
        reference.getMetadata().put("faqCollection", true);
        message.setKnowledgeReferences(List.of(reference));
        event.setMessage(message);

        List<Map<String, Object>> payload = invoke(service, "buildKnowledgeCitationPayload",
                new Class<?>[]{AgentRuntimeEvent.class}, event);

        Assert.assertEquals(1, payload.size());
        Assert.assertEquals("学生事务 FAQ", payload.get(0).get("knowledgeName"));
        Assert.assertEquals("暑假安排原文", payload.get(0).get("chunkContent"));
        Assert.assertEquals("FAQ", payload.get(0).get("knowledgeType"));
        Assert.assertEquals(Boolean.TRUE, payload.get(0).get("faqCollection"));
    }

    /**
     * 验证输入接收事件会回传正式图片展示信息。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void sendInputAcceptedShouldExposeBoundImages() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();
        AgentBoundMedia image = new AgentBoundMedia("formal:101:201:0:png", "image/png",
                Map.of("imageRef", "formal:101:201:0:png",
                        "previewUrl", "/api/v1/agent/media/content?reference=formal:101:201:0:png"));

        boolean sent = invoke(service, "sendInputAccepted",
                new Class<?>[]{ChatSseEmitter.class, BigInteger.class, BigInteger.class, List.class},
                emitter, BigInteger.valueOf(101), BigInteger.valueOf(201), List.of(image));

        Assert.assertTrue(sent);
        Assert.assertEquals(1, emitter.envelopes.size());
        Assert.assertEquals(ChatType.INPUT_ACCEPTED, emitter.envelopes.get(0).getType());
        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
        Assert.assertEquals("101", payload.get("sessionId"));
        Assert.assertEquals("201", payload.get("messageId"));
        Assert.assertEquals(List.of(image.payload()), payload.get("images"));
    }

    /**
     * 验证未保存草稿会生成临时 Agent ID，并把绑定指向该运行 ID。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void buildDraftAgentShouldGenerateRuntimeIdForUnsavedAgent() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentDraftChatRequest request = new AgentDraftChatRequest();
        Agent agent = new Agent();
        agent.setModelId(BigInteger.valueOf(10));
        request.setAgent(agent);

        AgentToolBinding toolBinding = new AgentToolBinding();
        toolBinding.setToolType("PLUGIN");
        toolBinding.setTargetId(BigInteger.valueOf(20));
        toolBinding.setResourceSnapshot(Map.of("name", "client-forged"));
        toolBinding.setResourceSummary(Map.of("name", "client-forged"));
        request.setToolBindings(List.of(toolBinding));

        AgentKnowledgeBinding knowledgeBinding = new AgentKnowledgeBinding();
        knowledgeBinding.setKnowledgeId(BigInteger.valueOf(30));
        knowledgeBinding.setResourceSnapshot(Map.of("title", "client-forged"));
        knowledgeBinding.setResourceSummary(Map.of("title", "client-forged"));
        request.setKnowledgeBindings(List.of(knowledgeBinding));

        LoginAccount account = new LoginAccount();
        account.setId(BigInteger.ONE);
        account.setTenantId(BigInteger.valueOf(2));
        account.setDeptId(BigInteger.valueOf(3));

        Agent draftAgent = invoke(service, "buildDraftAgent",
                new Class<?>[]{AgentDraftChatRequest.class, LoginAccount.class}, request, account);

        Assert.assertNotNull(draftAgent.getId());
        Assert.assertEquals(BigInteger.valueOf(2), draftAgent.getTenantId());
        Assert.assertEquals(draftAgent.getId(), draftAgent.getToolBindings().get(0).getAgentId());
        Assert.assertEquals(draftAgent.getId(), draftAgent.getKnowledgeBindings().get(0).getAgentId());
        Assert.assertTrue(draftAgent.getToolBindings().get(0).getEnabled());
        Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getEnabled());
        Assert.assertTrue(draftAgent.getToolBindings().get(0).getResourceSnapshot().isEmpty());
        Assert.assertTrue(draftAgent.getToolBindings().get(0).getResourceSummary().isEmpty());
        Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getResourceSnapshot().isEmpty());
        Assert.assertTrue(draftAgent.getKnowledgeBindings().get(0).getResourceSummary().isEmpty());
    }

    /**
     * 验证正式聊天在获取运行锁失败时不会提前写入用户消息。
     *
     * <p>运行锁是 AgentScope session 与 chatlog 的一致性入口。若同会话并发请求抢锁失败，
     * 必须在 prepareSession 和 recordUserMessage 之前失败，避免 chatlog 出现没有真实运行的用户消息。</p>
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void runShouldAcquireLockBeforePersistingUserMessage() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
        setField(service, "chatRuntimeManager", chatRuntimeManager);
        setField(service, "agentRunLock", new RejectingAgentRunLock());

        Agent agent = new Agent();
        agent.setId(BigInteger.valueOf(100));
        ChatRuntimeContext context = chatContext();
        LoginAccount account = new LoginAccount();
        account.setId(BigInteger.ONE);
        account.setTenantId(BigInteger.ONE);

        Exception thrown = Assert.assertThrows(Exception.class, () -> invoke(service, "run",
                new Class<?>[]{Agent.class, String.class, List.class, LoginAccount.class, String.class,
                        String.class, String.class, String.class, ChatRuntimeContext.class, boolean.class,
                        AgentSessionStore.class},
                agent, "你好", List.of(), account, "request-lock", "trace-lock", "session-lock", "AGENT",
                context, true, new InMemoryAgentSessionStore()));

        Assert.assertTrue(rootCause(thrown) instanceof BusinessException);
        Assert.assertEquals(0, chatRuntimeManager.prepareSessionCount);
        Assert.assertEquals(0, chatRuntimeManager.recordUserMessageCount);
    }

    /**
     * 验证草稿运行会使用独立 session store，且不会绑定 MySQL session 元信息。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void startRuntimeShouldUseDraftSessionStoreWithoutBindingMysqlSession() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingAgentRuntimeCompiler compiler = new RecordingAgentRuntimeCompiler();
        RecordingAgentRuntime runtime = new RecordingAgentRuntime();
        RecordingAgentRuntimeFactory runtimeFactory = new RecordingAgentRuntimeFactory(runtime);
        AgentSessionStore draftStore = new InMemoryAgentSessionStore();
        setField(service, "agentRuntimeCompiler", compiler);
        setField(service, "agentRuntimeFactory", runtimeFactory);
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        AgentMediaService mediaService = Mockito.mock(AgentMediaService.class);
        Mockito.when(mediaService.runtimeResolver(Mockito.any())).thenReturn(reference -> null);
        setField(service, "agentMediaService", mediaService);

        Agent agent = new Agent();
        agent.setId(BigInteger.valueOf(100));
        LoginAccount account = new LoginAccount();
        account.setId(BigInteger.ONE);
        account.setTenantId(BigInteger.ONE);
        invoke(service, "startRuntime",
                new Class<?>[]{Agent.class, AgentMessage.class, LoginAccount.class, String.class, String.class,
                        String.class, String.class, ChatRuntimeContext.class, ChatSseEmitter.class, boolean.class,
                        AgentSessionStore.class, AgentRunLock.Handle.class},
                agent, AgentMessage.text(AgentMessageRole.USER, "你好"), account,
                "request-draft", "trace-draft", "agent-draft-100", "AGENT_DRAFT",
                chatContext(), new RecordingChatSseEmitter(), false, draftStore, null);

        Assert.assertSame(draftStore, runtime.initRequest.getSessionStore());
    }

    /**
     * 验证草稿事件不会写运行事件表，正式事件仍会记录。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldOnlyPersistEventsForFormalChat() throws Exception {
        AgentRunService service = new AgentRunService();
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        RecordingAgentRunEventRecorder recorder = new RecordingAgentRunEventRecorder();
        setField(service, "agentRunEventRecorder", recorder);
        AgentRuntimeEvent draftEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
        draftEvent.getPayload().put("toolName", "search");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                draftEvent, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
                new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);

        Assert.assertEquals(0, recorder.recordCount);

        AgentRuntimeEvent formalEvent = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_CALL);
        formalEvent.getPayload().put("toolName", "search");
        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                formalEvent, "request-formal", new RecordingChatSseEmitter(), new StringBuilder(),
                new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), true);

        Assert.assertEquals(1, recorder.recordCount);
    }

    /**
     * 验证草稿工具审批只注册内存恢复令牌，不写 HITL pending 表。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void draftToolApprovalShouldNotPersistPending() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRunRegistry registry = new AgentRunRegistry();
        RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
        setField(service, "agentRunRegistry", registry);
        setField(service, "agentHitlPendingService", pendingService);
        registry.register(runContext("request-draft", "agent-draft-tool", false));
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.TOOL_APPROVAL_REQUIRED);
        event.getPayload().put("resumeToken", "token-draft");

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-draft", new RecordingChatSseEmitter(), new StringBuilder(),
                new ChatAssistantAccumulator(), chatContext(), new AtomicBoolean(false), false);

        Assert.assertTrue(registry.containsResumeTarget("request-draft", "token-draft"));
        Assert.assertEquals(0, pendingService.recordApprovalRequiredCount);
    }

    /**
     * 验证草稿审批恢复不执行 pending 表消费，正式审批仍执行。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void approveShouldSkipPendingConsumeOnlyForDraftRun() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRunRegistry registry = new AgentRunRegistry();
        RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
        setField(service, "agentRunRegistry", registry);
        setField(service, "agentHitlPendingService", pendingService);

        registry.register(runContext("request-draft-approve", "agent-draft-approve", false));
        registry.registerResumeToken("request-draft-approve", "token-draft-approve");
        invoke(service, "approveRuntime",
                new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                "request-draft-approve", "token-draft-approve", BigInteger.ONE, "1");

        Assert.assertEquals(0, pendingService.approveCount);

        registry.register(runContext("request-formal-approve", "session-formal-approve", true));
        registry.registerResumeToken("request-formal-approve", "token-formal-approve");
        invoke(service, "approveRuntime",
                new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                "request-formal-approve", "token-formal-approve", BigInteger.ONE, "1");

        Assert.assertEquals(1, pendingService.approveCount);
    }

    /**
     * 验证本机存在恢复目标时不投递远程命令。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void approveShouldNotDispatchRemoteWhenLocalRuntimeExists() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRunRegistry registry = new AgentRunRegistry();
        RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
        RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-a");
        RecordingCommandProducer commandProducer = new RecordingCommandProducer();
        setField(service, "agentRunRegistry", registry);
        setField(service, "agentHitlPendingService", pendingService);
        setField(service, "agentRuntimeRouteRegistry", routeRegistry);
        setField(service, "agentRuntimeCommandProducer", commandProducer);

        registry.register(runContext("request-local-approve", "session-local-approve", true));
        registry.registerResumeToken("request-local-approve", "token-local-approve");
        invoke(service, "approveRuntime",
                new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                "request-local-approve", "token-local-approve", BigInteger.ONE, "1");

        Assert.assertEquals(1, pendingService.approveCount);
        Assert.assertEquals(0, commandProducer.approveCount);
    }

    /**
     * 验证本机无运行态但 Redis owner 存在时投递远程命令。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void approveShouldDispatchRemoteWhenOwnerIsRemoteNode() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
        routeRegistry.requestIdByToken = "request-remote-approve";
        routeRegistry.ownerNode = "node-a";
        routeRegistry.ownerBootId = "boot-a";
        routeRegistry.currentOwnerBootId = "boot-a";
        routeRegistry.nodeAlive = true;
        RecordingCommandProducer commandProducer = new RecordingCommandProducer();
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        setField(service, "agentRuntimeRouteRegistry", routeRegistry);
        setField(service, "agentRuntimeCommandProducer", commandProducer);

        invoke(service, "approveRuntime",
                new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                null, "token-remote-approve", BigInteger.ONE, "1");

        Assert.assertEquals(1, commandProducer.approveCount);
        Assert.assertEquals("node-a", commandProducer.lastTargetNodeId);
        Assert.assertEquals("request-remote-approve", commandProducer.lastRequestId);
    }

    /**
     * 验证 owner 缺失时明确失败。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void approveShouldFailWhenOwnerRouteMissing() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
        routeRegistry.requestIdByToken = "request-missing-owner";
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        setField(service, "agentRuntimeRouteRegistry", routeRegistry);
        setField(service, "agentRuntimeCommandProducer", new RecordingCommandProducer());

        try {
            invoke(service, "approveRuntime",
                    new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                    null, "token-missing-owner", BigInteger.ONE, "1");
            Assert.fail("expected BusinessException");
        } catch (Exception e) {
            Assert.assertTrue(rootCause(e) instanceof BusinessException);
        }
    }

    /**
     * 验证 owner 重启后启动代不匹配会明确失败。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void approveShouldFailWhenOwnerBootIdChanged() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
        routeRegistry.requestIdByToken = "request-restarted-owner";
        routeRegistry.ownerNode = "node-a";
        routeRegistry.ownerBootId = "boot-old";
        routeRegistry.currentOwnerBootId = "boot-new";
        routeRegistry.nodeAlive = true;
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        setField(service, "agentRuntimeRouteRegistry", routeRegistry);
        setField(service, "agentRuntimeCommandProducer", new RecordingCommandProducer());

        try {
            invoke(service, "approveRuntime",
                    new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                    null, "token-restarted-owner", BigInteger.ONE, "1");
            Assert.fail("expected BusinessException");
        } catch (Exception e) {
            Assert.assertTrue(rootCause(e) instanceof BusinessException);
        }
    }

    /**
     * 验证 owner 路由存在但节点心跳缺失时明确失败。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void approveShouldFailWhenOwnerNodeHeartbeatMissing() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingRouteRegistry routeRegistry = new RecordingRouteRegistry("node-b");
        routeRegistry.requestIdByToken = "request-offline-owner";
        routeRegistry.ownerNode = "node-a";
        routeRegistry.nodeAlive = false;
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        setField(service, "agentRuntimeRouteRegistry", routeRegistry);
        setField(service, "agentRuntimeCommandProducer", new RecordingCommandProducer());

        try {
            invoke(service, "approveRuntime",
                    new Class<?>[]{String.class, String.class, BigInteger.class, String.class},
                    null, "token-offline-owner", BigInteger.ONE, "1");
            Assert.fail("expected BusinessException");
        } catch (Exception e) {
            Assert.assertTrue(rootCause(e) instanceof BusinessException);
        }
    }

    /**
     * 验证清理草稿会话只清草稿 store，不触碰 MySQL pending 清理。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void clearDraftSessionShouldOnlyDeleteDraftStore() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingAgentHitlPendingService pendingService = new RecordingAgentHitlPendingService();
        RecordingAgentSessionStore draftStore = new RecordingAgentSessionStore();
        setField(service, "agentRunRegistry", new AgentRunRegistry());
        setField(service, "agentHitlPendingService", pendingService);
        setField(service, "draftAgentSessionStore", draftStore);

        invoke(service, "clearDraftSessionInternal",
                new Class<?>[]{String.class, String.class}, "agent-draft-clear", "1");

        Assert.assertEquals("agent-draft-clear", draftStore.deletedSessionKey);
        Assert.assertEquals(0, pendingService.deleteByRuntimeSessionIdCount);
    }

    /**
     * 验证正式聊天会在会话准备完成后向前端返回真实会话 ID。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void sendSessionCreatedShouldExposeSessionId() throws Exception {
        AgentRunService service = new AgentRunService();
        RecordingChatSseEmitter emitter = new RecordingChatSseEmitter();

        Boolean sent = invoke(service, "sendSessionCreated",
                new Class<?>[]{ChatSseEmitter.class, BigInteger.class}, emitter, BigInteger.valueOf(123));

        Assert.assertTrue(sent);
        Assert.assertEquals(1, emitter.envelopes.size());
        Assert.assertEquals(ChatDomain.SYSTEM, emitter.envelopes.get(0).getDomain());
        Assert.assertEquals(ChatType.SESSION_CREATED, emitter.envelopes.get(0).getType());
        @SuppressWarnings("unchecked")
        Map<String, Object> payload = (Map<String, Object>) emitter.envelopes.get(0).getPayload();
        Assert.assertEquals("123", payload.get("sessionId"));
    }

    /**
     * 验证正式聊天只有新会话首轮会自动设置默认标题。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void applyFormalSessionTitleShouldOnlyUseFirstPromptForNewSession() throws Exception {
        AgentRunService service = new AgentRunService();
        ChatRuntimeContext newContext = chatContext();
        ChatRuntimeContext existingContext = chatContext();
        ChatSessionSummary existingSession = new ChatSessionSummary();
        existingSession.setId(BigInteger.valueOf(123));
        existingSession.setTitle("用户改过的标题");
        existingSession.setMessageCount(2);

        invoke(service, "applyFormalSessionTitle",
                new Class<?>[]{ChatRuntimeContext.class, String.class, ChatSessionSummary.class},
                newContext, "第一句话", null);
        invoke(service, "applyFormalSessionTitle",
                new Class<?>[]{ChatRuntimeContext.class, String.class, ChatSessionSummary.class},
                existingContext, "后续消息", existingSession);

        Assert.assertEquals("第一句话", newContext.getSessionTitle());
        Assert.assertNull(existingContext.getSessionTitle());
    }

    /**
     * 验证正式聊天 SSE 断开后会取消运行并保存断开前已输出的 assistant 内容。
     *
     * @throws Exception 反射调用失败时抛出
     */
    @Test
    public void handleRuntimeEventShouldCancelAndRecordFailureWhenSseDisconnected() throws Exception {
        AgentRunService service = new AgentRunService();
        AgentRunRegistry registry = new AgentRunRegistry();
        RecordingChatRuntimeManager chatRuntimeManager = new RecordingChatRuntimeManager();
        setField(service, "agentRunRegistry", registry);
        setField(service, "chatRuntimeManager", chatRuntimeManager);

        AtomicBoolean finished = new AtomicBoolean(false);
        ChatRuntimeContext context = chatContext();
        AgentRunRegistry.AgentRunContext runContext = new AgentRunRegistry.AgentRunContext(
                "request-disconnected",
                "session-disconnected",
                new NoopRuntime(),
                new FailingChatSseEmitter(),
                context,
                new StringBuilder(),
                new ChatAssistantAccumulator(),
                finished,
                true,
                new AgentRunRegistry.RunOwner("agent-1", "session-disconnected", "user-1"),
                null,
                ignored -> {
                },
                ignored -> {
                },
                () -> {
                }
        );
        registry.register(runContext);
        AgentRuntimeEvent event = AgentRuntimeEvent.of(AgentRuntimeEventType.MESSAGE_DELTA);
        event.getPayload().put("text", "断连前正文");
        StringBuilder answer = new StringBuilder();
        ChatAssistantAccumulator assistantAccumulator = new ChatAssistantAccumulator();

        invoke(service, "handleRuntimeEvent",
                runtimeEventParameterTypes(),
                event, "request-disconnected", new FailingChatSseEmitter(), answer,
                assistantAccumulator, context, finished, true);

        Assert.assertTrue(finished.get());
        Assert.assertNull(registry.get("request-disconnected"));
        Assert.assertEquals(1, chatRuntimeManager.recordAssistantCompletedCount);
        Assert.assertEquals("断连前正文", chatRuntimeManager.lastAssistantMessage.getContentText());
        Assert.assertEquals(1, chatRuntimeManager.recordFailureCount);
        Assert.assertEquals(0, chatRuntimeManager.recordCompletedCount);
    }

    @SuppressWarnings("unchecked")
    private <T> T invoke(Object target, String methodName, Class<?>[] parameterTypes, Object... args) throws Exception {
        Method method = target.getClass().getDeclaredMethod(methodName, parameterTypes);
        method.setAccessible(true);
        return (T) method.invoke(target, args);
    }

    private void setField(Object target, String fieldName, Object value) throws Exception {
        java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName);
        field.setAccessible(true);
        field.set(target, value);
    }

    private Class<?>[] runtimeEventParameterTypes() {
        return new Class<?>[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class,
                ChatAssistantAccumulator.class,
                ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
    }

    private Class<?>[] legacyRuntimeEventParameterTypes() {
        return new Class<?>[]{AgentRuntimeEvent.class, String.class, ChatSseEmitter.class, StringBuilder.class,
                ChatAssistantAccumulator.class, LegacyThinkingTagParser.class,
                ChatRuntimeContext.class, AtomicBoolean.class, boolean.class};
    }

    private AgentRunRegistry.AgentRunContext runContext(String requestId, String sessionId, boolean persistChatlog) {
        return new AgentRunRegistry.AgentRunContext(
                requestId,
                sessionId,
                new RecordingAgentRuntime(),
                new RecordingChatSseEmitter(),
                chatContext(),
                new StringBuilder(),
                new ChatAssistantAccumulator(),
                new AtomicBoolean(false),
                persistChatlog,
                new AgentRunRegistry.RunOwner("agent-1", sessionId, "1"),
                null,
                event -> {
                },
                error -> {
                },
                () -> {
                }
        );
    }

    private ChatRuntimeContext chatContext() {
        ChatRuntimeContext context = new ChatRuntimeContext();
        context.setAssistantId(BigInteger.valueOf(100));
        context.setAssistantName("Agent");
        context.setUserId(BigInteger.valueOf(101));
        context.setUserName("用户");
        return context;
    }

    private Throwable rootCause(Throwable throwable) {
        Throwable current = throwable;
        while (current.getCause() != null) {
            current = current.getCause();
        }
        return current;
    }

    /**
     * 记录发送内容的 SSE 测试桩。
     */
    private static class RecordingChatSseEmitter extends ChatSseEmitter {

        private final List<ChatEnvelope<?>> envelopes = new java.util.ArrayList<>();

        @Override
        public boolean send(ChatEnvelope<?> envelope) {
            envelopes.add(envelope);
            return true;
        }

        @Override
        public boolean sendDone(ChatEnvelope<?> envelope) {
            envelopes.add(envelope);
            return true;
        }
    }

    private static class FailingChatSseEmitter extends ChatSseEmitter {

        @Override
        public boolean send(ChatEnvelope<?> envelope) {
            return false;
        }

        @Override
        public boolean sendDone(ChatEnvelope<?> envelope) {
            return false;
        }
    }

    private static class NoopRuntime implements com.easyagents.agent.runtime.AgentRuntime {

        @Override
        public void init(com.easyagents.agent.runtime.AgentInitRequest request) {
            // 测试桩无需初始化。
        }

        @Override
        public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
            return reactor.core.publisher.Flux.empty();
        }

        @Override
        public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
            return reactor.core.publisher.Flux.empty();
        }
    }

    private static class RecordingAgentRuntime implements AgentRuntime {

        private AgentInitRequest initRequest;
        private int resumeCount;

        @Override
        public void init(AgentInitRequest request) {
            initRequest = request;
        }

        @Override
        public reactor.core.publisher.Flux<AgentRuntimeEvent> stream(AgentMessage userMessage) {
            return reactor.core.publisher.Flux.empty();
        }

        @Override
        public reactor.core.publisher.Flux<AgentRuntimeEvent> resume(com.easyagents.agent.runtime.AgentResumeRequest request) {
            resumeCount++;
            return reactor.core.publisher.Flux.empty();
        }
    }

    private static class RecordingRouteRegistry extends AgentRuntimeRouteRegistry {

        private final String currentNodeId;
        private String ownerNode;
        private String ownerBootId;
        private String currentOwnerBootId;
        private String requestIdByToken;
        private boolean nodeAlive;

        private RecordingRouteRegistry(String currentNodeId) {
            super(null, null, null);
            this.currentNodeId = currentNodeId;
        }

        @Override
        public String findOwnerNode(String requestId) {
            return ownerNode;
        }

        @Override
        public AgentRuntimeRoute findOwnerRoute(String requestId) {
            AgentRuntimeRoute route = new AgentRuntimeRoute();
            route.setNodeId(ownerNode);
            route.setBootId(ownerBootId);
            return route;
        }

        @Override
        public String findRequestIdByResumeToken(String resumeToken) {
            return requestIdByToken;
        }

        @Override
        public String currentNodeId() {
            return currentNodeId;
        }

        @Override
        public boolean isNodeAlive(String nodeId) {
            return nodeAlive;
        }

        @Override
        public String currentNodeBootId(String nodeId) {
            return currentOwnerBootId;
        }
    }

    private static class RecordingCommandProducer extends AgentRuntimeCommandProducer {

        private int approveCount;
        private String lastTargetNodeId;
        private String lastRequestId;

        @Override
        public void sendApprove(String targetNodeId,
                                String requestId,
                                String resumeToken,
                                BigInteger operatorId,
                                String userId) {
            approveCount++;
            lastTargetNodeId = targetNodeId;
            lastRequestId = requestId;
        }
    }

    private static class RecordingAgentRuntimeFactory implements AgentRuntimeFactory {

        private final AgentRuntime runtime;

        private RecordingAgentRuntimeFactory(AgentRuntime runtime) {
            this.runtime = runtime;
        }

        @Override
        public AgentRuntime create() {
            return runtime;
        }
    }

    private static class RecordingAgentRuntimeCompiler extends AgentRuntimeCompiler {

        @Override
        public AgentRuntimeBundle compile(Agent agent) {
            AgentRuntimeBundle bundle = new AgentRuntimeBundle();
            bundle.setDefinition(new com.easyagents.agent.runtime.AgentDefinition());
            return bundle;
        }
    }

    private static class RecordingAgentRunEventRecorder implements AgentRunEventRecorder {

        private int recordCount;

        @Override
        public void record(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
            recordCount++;
        }
    }

    private static class RecordingAgentHitlPendingService implements AgentHitlPendingService {

        private int recordApprovalRequiredCount;
        private int approveCount;
        private int rejectCount;
        private int cancelByRequestIdCount;
        private int deleteByRuntimeSessionIdCount;

        @Override
        public void recordApprovalRequired(String requestId, ChatRuntimeContext chatContext, AgentRuntimeEvent event) {
            recordApprovalRequiredCount++;
        }

        @Override
        public AgentHitlPending approve(String resumeToken, BigInteger operatorId) {
            approveCount++;
            return new AgentHitlPending();
        }

        @Override
        public AgentHitlPending reject(String resumeToken, BigInteger operatorId, String reason) {
            rejectCount++;
            return new AgentHitlPending();
        }

        @Override
        public void cancelByRequestId(String requestId, String reason) {
            cancelByRequestIdCount++;
        }

        @Override
        public void deleteByChatSessionId(BigInteger chatSessionId) {
            // 测试桩无需处理。
        }

        @Override
        public void deleteByRuntimeSessionId(String runtimeSessionId) {
            deleteByRuntimeSessionIdCount++;
        }

        @Override
        public List<AgentHitlPending> expirePending(int limit) {
            return List.of();
        }
    }

    private static class RecordingAgentSessionStore implements AgentSessionStore {

        private String deletedSessionKey;

        @Override
        public void save(String sessionKey, String name, io.agentscope.core.state.State state) {
            // 测试桩无需处理。
        }

        @Override
        public void saveList(String sessionKey, String name, List<? extends io.agentscope.core.state.State> states) {
            // 测试桩无需处理。
        }

        @Override
        public <T extends io.agentscope.core.state.State> java.util.Optional<T> get(String sessionKey, String name, Class<T> type) {
            return java.util.Optional.empty();
        }

        @Override
        public <T extends io.agentscope.core.state.State> List<T> getList(String sessionKey, String name, Class<T> itemType) {
            return List.of();
        }

        @Override
        public boolean exists(String sessionKey) {
            return false;
        }

        @Override
        public void delete(String sessionKey) {
            deletedSessionKey = sessionKey;
        }

        @Override
        public java.util.Set<String> listSessionKeys() {
            return java.util.Set.of();
        }
    }

    /**
     * 记录 chatlog 写入动作的测试桩。
     */
    private static class RecordingChatRuntimeManager implements ChatRuntimeManager {

        private int prepareSessionCount;
        private int recordUserMessageCount;
        private int recordAssistantCompletedCount;
        private int recordFailureCount;
        private int recordCompletedCount;
        private ChatRuntimeMessage lastAssistantMessage;

        @Override
        public void prepareSession(ChatRuntimeContext context) {
            prepareSessionCount++;
        }

        @Override
        public void recordUserMessage(ChatRuntimeContext context, ChatRuntimeMessage message) {
            recordUserMessageCount++;
        }

        @Override
        public void recordAssistantDelta(ChatRuntimeContext context, ChatRuntimeMessage message) {
            // 测试桩无需记录。
        }

        @Override
        public void recordAssistantCompleted(ChatRuntimeContext context, ChatRuntimeMessage message) {
            recordAssistantCompletedCount++;
            lastAssistantMessage = message;
        }

        @Override
        public void recordFailure(ChatRuntimeContext context, Throwable throwable) {
            recordFailureCount++;
        }

        @Override
        public void recordCompleted(ChatRuntimeContext context) {
            recordCompletedCount++;
        }

        @Override
        public List<ChatRuntimeMessage> loadMessages(ChatRuntimeContext context, int limit) {
            return List.of();
        }
    }

    /**
     * 模拟同会话运行锁已被占用的测试桩。
     */
    private static class RejectingAgentRunLock implements AgentRunLock {

        @Override
        public Handle acquire(BigInteger agentId, String sessionId) {
            throw new BusinessException("当前 Agent 会话已有运行中的请求，请稍后再试");
        }
    }
}
