import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const { gatewayRpcMock, agentsState, hostApiFetchMock } = vi.hoisted(() => ({
  gatewayRpcMock: vi.fn(),
  agentsState: {
    agents: [] as Array<Record<string, unknown>>,
  },
  hostApiFetchMock: vi.fn(),
}));

vi.mock('@/stores/gateway', () => ({
  useGatewayStore: {
    getState: () => ({
      status: { state: 'running', port: 18789, connectedAt: Date.now() },
      rpc: gatewayRpcMock,
    }),
  },
}));

vi.mock('@/stores/agents', () => ({
  useAgentsStore: {
    getState: () => agentsState,
  },
}));

vi.mock('@/lib/host-api', () => ({
  hostApiFetch: (...args: unknown[]) => hostApiFetchMock(...args),
}));

describe('useChatStore startup history retry', () => {
  beforeEach(() => {
    vi.resetModules();
    vi.useFakeTimers();
    window.localStorage.clear();
    agentsState.agents = [];
    gatewayRpcMock.mockReset();
    hostApiFetchMock.mockReset();
    hostApiFetchMock.mockResolvedValue({ messages: [] });
  });

  afterEach(() => {
    vi.useRealTimers();
  });

  it('uses the longer timeout only for the initial foreground history load', async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }],
      })
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'quiet refresh', timestamp: 1001 }],
      });

    await useChatStore.getState().loadHistory(false);
    vi.advanceTimersByTime(1_000);
    await useChatStore.getState().loadHistory(true);

    expect(gatewayRpcMock).toHaveBeenNthCalledWith(
      1,
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      35_000,
    );
    expect(gatewayRpcMock).toHaveBeenNthCalledWith(
      2,
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      undefined,
    );
    expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
    setTimeoutSpy.mockRestore();
  });

  it('forces the internal final-message reload through the quiet history cooldown', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock
      .mockResolvedValueOnce({
        messages: [{ role: 'user', content: 'hello', id: 'u1', timestamp: 1000 }],
      })
      .mockResolvedValueOnce({
        messages: [
          { role: 'user', content: 'hello', id: 'u1', timestamp: 1000 },
          { role: 'assistant', content: 'Real answer', id: 'a2', timestamp: 1001 },
        ],
      });

    await useChatStore.getState().loadHistory(true);
    useChatStore.setState({
      sending: true,
      activeRunId: 'run-internal',
      streamingText: 'NO_REPLY',
      streamingMessage: { role: 'assistant', content: 'NO_REPLY' },
    });

    useChatStore.getState().handleChatEvent({
      state: 'final',
      runId: 'run-internal',
      sessionKey: 'agent:main:main',
      message: { role: 'assistant', content: 'NO_REPLY', id: 'a1' },
    });

    await Promise.resolve();
    await Promise.resolve();

    expect(gatewayRpcMock).toHaveBeenCalledTimes(2);
    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
      'hello',
      'Real answer',
    ]);
    expect(useChatStore.getState().sending).toBe(false);
    expect(useChatStore.getState().activeRunId).toBeNull();
  });

  it('keeps non-startup foreground loading safety timeout at 15 seconds', async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'first load', timestamp: 1000 }],
      })
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'second foreground load', timestamp: 1001 }],
      });

    await useChatStore.getState().loadHistory(false);
    setTimeoutSpy.mockClear();
    await useChatStore.getState().loadHistory(false);

    expect(gatewayRpcMock).toHaveBeenNthCalledWith(
      2,
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      undefined,
    );
    expect(setTimeoutSpy).not.toHaveBeenCalledWith(expect.any(Function), 15_000);
    setTimeoutSpy.mockRestore();
  });

  it('keeps cached session messages visible without foreground loading overlay during refresh', async () => {
    const { useChatStore } = await import('@/stores/chat');

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }],
      })
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }],
      });

    useChatStore.setState({ currentSessionKey: 'agent:main:other' });
    await useChatStore.getState().loadHistory(false);

    gatewayRpcMock.mockImplementationOnce(() => new Promise((resolve) => {
      setTimeout(() => {
        resolve({ messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }] });
      }, 10);
    }));

    useChatStore.getState().switchSession('agent:main:other');

    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']);
    expect(useChatStore.getState().loading).toBe(false);
  });

  it('switchSession restores cached session messages immediately while refreshing in background', async () => {
    const { useChatStore } = await import('@/stores/chat');

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'cached history', timestamp: 1000 }],
      })
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'main history', timestamp: 1001 }],
      });

    useChatStore.setState({ currentSessionKey: 'agent:main:other' });
    await useChatStore.getState().loadHistory(false);

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [{ role: 'assistant', content: 'refreshed cached history', timestamp: 1002 }],
    });

    useChatStore.getState().switchSession('agent:main:other');

    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['cached history']);
  });

  it('does not re-arm Thinking state for stale main-session heartbeat tool history', async () => {
    const { useChatStore } = await import('@/stores/chat');

    gatewayRpcMock.mockResolvedValue({
      messages: [
        { id: 'user-old', role: 'user', content: 'old question', timestamp: 1_000 },
        {
          id: 'assistant-tool',
          role: 'assistant',
          content: [{ type: 'toolCall', id: 'tool-1', name: 'read', arguments: { path: '~/.openclaw/workspace/HEARTBEAT.md' } }],
          stopReason: 'toolUse',
          timestamp: 1_100,
        },
      ],
    });

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    await useChatStore.getState().loadHistory(false);

    expect(useChatStore.getState().sending).toBe(false);
    expect(useChatStore.getState().activeRunId).toBeNull();
  });

  it('switchSession preserves unsynced optimistic user messages when switching back', async () => {
    const { useChatStore } = await import('@/stores/chat');
    const optimisticHello = {
      id: 'user-hello-opt',
      role: 'user' as const,
      content: '你好',
      timestamp: 1_700_000_000,
    };

    useChatStore.setState({
      currentSessionKey: 'agent:main:session-a',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-a' }, { key: 'agent:main:other' }],
      messages: [optimisticHello],
      sessionLabels: {},
      sessionLastActivity: { 'agent:main:session-a': Date.now() },
      sending: true,
      activeRunId: 'run-hello',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: Date.now(),
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValue({ messages: [] });

    useChatStore.getState().switchSession('agent:main:other');
    useChatStore.getState().switchSession('agent:main:session-a');

    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['你好']);
  });

  it('switchSession restores in-flight run state so Thinking indicator survives navigation', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-run',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-run' }, { key: 'agent:main:other' }],
      messages: [
        { id: 'user-run', role: 'user', content: 'browse the page', timestamp: 1000 },
        {
          id: 'assistant-tool-run',
          role: 'assistant',
          content: [
            { type: 'toolCall', id: 'tool-run', name: 'browser', input: { action: 'snapshot' } },
          ],
          stopReason: 'toolUse',
          timestamp: 1500,
        },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-nav-test',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: true,
      lastUserMessageAt: 1000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    useChatStore.getState().switchSession('agent:main:other');
    expect(useChatStore.getState().sending).toBe(false);

    useChatStore.getState().switchSession('agent:main:session-run');

    const state = useChatStore.getState();
    expect(state.sending).toBe(true);
    expect(state.activeRunId).toBe('run-nav-test');
    expect(state.pendingFinal).toBe(true);
    expect(state.lastUserMessageAt).toBe(1000);
  });

  it('treats the same session as a fresh foreground load after gateway runtime changes', async () => {
    const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'first runtime', timestamp: 1000 }],
      })
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'second runtime', timestamp: 1001 }],
      });

    await useChatStore.getState().loadHistory(false);

    vi.resetModules();
    vi.doMock('@/stores/gateway', () => ({
      useGatewayStore: {
        getState: () => ({
          status: { state: 'running', port: 18789, connectedAt: Date.now() + 5_000 },
          rpc: gatewayRpcMock,
        }),
      },
    }));
    const { useChatStore: useChatStoreReloaded } = await import('@/stores/chat');
    useChatStoreReloaded.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    setTimeoutSpy.mockClear();
    await useChatStoreReloaded.getState().loadHistory(false);

    expect(gatewayRpcMock).toHaveBeenLastCalledWith(
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      35_000,
    );
    expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 191_800);
    setTimeoutSpy.mockRestore();
  });

  it('does not burn the first-load retry path when the first attempt becomes stale', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const { useChatStore } = await import('@/stores/chat');

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    let resolveFirstAttempt: ((value: { messages: Array<{ role: string; content: string; timestamp: number }> }) => void) | null = null;
    gatewayRpcMock
      .mockImplementationOnce(() => new Promise((resolve) => {
        resolveFirstAttempt = resolve;
      }))
      .mockRejectedValueOnce(new Error('RPC timeout: chat.history'))
      .mockResolvedValueOnce({
        messages: [{ role: 'assistant', content: 'restored after retry', timestamp: 1002 }],
      });

    const firstLoad = useChatStore.getState().loadHistory(false);
    useChatStore.setState({
      currentSessionKey: 'agent:main:other',
      messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }],
    });
    resolveFirstAttempt?.({
      messages: [{ role: 'assistant', content: 'stale original payload', timestamp: 1000 }],
    });
    await firstLoad;

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      messages: [],
    });
    const secondLoad = useChatStore.getState().loadHistory(false);
    await vi.runAllTimersAsync();
    await secondLoad;

    expect(gatewayRpcMock).toHaveBeenCalledTimes(3);
    expect(gatewayRpcMock.mock.calls[0]).toEqual([
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      35_000,
    ]);
    expect(gatewayRpcMock.mock.calls[1]).toEqual([
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      35_000,
    ]);
    expect(gatewayRpcMock.mock.calls[2]).toEqual([
      'chat.history',
      { sessionKey: 'agent:main:main', limit: 200 },
      35_000,
    ]);
    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['restored after retry']);
    expect(warnSpy).toHaveBeenCalledWith(
      '[chat.history] startup retry scheduled',
      expect.objectContaining({
        sessionKey: 'agent:main:main',
        attempt: 1,
      }),
    );
    warnSpy.mockRestore();
  });

  it('stops retrying once the user switches sessions mid-load', async () => {
    const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
    const { useChatStore } = await import('@/stores/chat');

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }, { key: 'agent:main:other' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockImplementationOnce(async () => {
      useChatStore.setState({
        currentSessionKey: 'agent:main:other',
        messages: [{ role: 'assistant', content: 'other session', timestamp: 1001 }],
        loading: false,
      });
      throw new Error('RPC timeout: chat.history');
    });

    await useChatStore.getState().loadHistory(false);

    expect(gatewayRpcMock).toHaveBeenCalledTimes(1);
    expect(useChatStore.getState().currentSessionKey).toBe('agent:main:other');
    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['other session']);
    expect(useChatStore.getState().error).toBeNull();
    expect(warnSpy).not.toHaveBeenCalled();
    warnSpy.mockRestore();
  });

  it('keeps the optimistic user message when completion refresh wins the transcript write race', async () => {
    const { useChatStore } = await import('@/stores/chat');
    let historyMessages: Array<Record<string, unknown>> = [];
    let resolveSend: ((value: { runId: string }) => void) | null = null;

    gatewayRpcMock.mockImplementation((method: string) => {
      if (method === 'chat.send') {
        return new Promise((resolve) => {
          resolveSend = resolve as (value: { runId: string }) => void;
        });
      }
      if (method === 'chat.history') {
        return Promise.resolve({ messages: historyMessages });
      }
      return Promise.resolve({});
    });

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    const sendPromise = useChatStore.getState().sendMessage('hello from app');
    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']);

    // Simulate Gateway phase=end clearing send state before chat.history has
    // persisted the user turn.
    useChatStore.setState({
      sending: false,
      activeRunId: null,
      pendingFinal: false,
      lastUserMessageAt: null,
    });

    await useChatStore.getState().loadHistory(true);
    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual(['hello from app']);

    historyMessages = [{
      role: 'user',
      content: 'hello from app',
      timestamp: Date.now() / 1000,
      id: 'server-user',
    }];
    vi.advanceTimersByTime(1_000);
    await useChatStore.getState().loadHistory(true);

    expect(useChatStore.getState().messages).toHaveLength(1);
    expect(useChatStore.getState().messages[0]).toMatchObject({
      id: 'server-user',
      role: 'user',
      content: 'hello from app',
    });

    resolveSend?.({ runId: 'run-1' });
    await sendPromise;
  });

  it('does not restore a pending optimistic message after deleting the session', async () => {
    const { useChatStore } = await import('@/stores/chat');
    let resolveSend: ((value: { runId: string }) => void) | null = null;

    gatewayRpcMock.mockImplementation((method: string) => {
      if (method === 'chat.send') {
        return new Promise((resolve) => {
          resolveSend = resolve as (value: { runId: string }) => void;
        });
      }
      if (method === 'chat.history') {
        return Promise.resolve({ messages: [] });
      }
      return Promise.resolve({});
    });
    hostApiFetchMock.mockImplementation((url: string) => {
      if (url === '/api/sessions/delete') {
        return Promise.resolve({ success: true });
      }
      return Promise.resolve({ messages: [] });
    });

    useChatStore.setState({
      currentSessionKey: 'agent:main:main',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:main' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    const sendPromise = useChatStore.getState().sendMessage('message that will be deleted');
    expect(useChatStore.getState().messages.map((message) => message.content)).toEqual([
      'message that will be deleted',
    ]);

    await useChatStore.getState().deleteSession('agent:main:main');
    expect(useChatStore.getState().messages).toEqual([]);

    await useChatStore.getState().loadHistory(true);
    expect(useChatStore.getState().messages).toEqual([]);

    resolveSend?.({ runId: 'run-deleted' });
    await sendPromise;
  });

  // Regression for the "thinking disappears mid-tool-chain" bug:
  // when the history-poll loads an intermediate `[thinking, toolCall]` assistant
  // message (stop_reason=tool_use) the closer half of applyLoadedMessages used
  // to match it as a "final reply" — because `hasNonToolAssistantContent` once
  // counted thinking blocks as user-visible content — and clear sending /
  // activeRunId / pendingFinal. That caused the Execution Graph card to flip to
  // inactive, the Thinking… dot to vanish, and ChatInput's stop button to
  // revert to a send button while the agent was still running tools.
  it('keeps the run open across intermediate [thinking, toolCall] history snapshots', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-1',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-1' }],
      messages: [
        { id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-keep-open',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: 1000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-1', role: 'user', content: '帮我查一下昆明未来七天的天气', timestamp: 1000 },
        {
          id: 'assistant-tool-1',
          role: 'assistant',
          content: [
            { type: 'thinking', thinking: 'Let me search for the weather.' },
            { type: 'toolCall', id: 'tool-1', name: 'web_search', input: { query: 'Kunming weather' } },
          ],
          stopReason: 'toolUse',
          timestamp: 1500,
        },
      ],
    });

    await useChatStore.getState().loadHistory(true);

    const state = useChatStore.getState();
    expect(state.sending).toBe(true);
    expect(state.activeRunId).toBe('run-keep-open');
    expect(state.pendingFinal).toBe(false);
  });

  // Regression for the mixed `[thinking, text, toolCall]` shape some models
  // emit. Even though it carries user-visible text, stop_reason=tool_use means
  // the assistant is still pending a tool result and the lifecycle must stay
  // armed. Without `hasPendingToolUse`, the closer would match this on the
  // text block and clear sending.
  it('keeps the run open for mixed [thinking, text, toolCall] turns with stopReason=toolUse', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-2',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-2' }],
      messages: [
        { id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-mixed',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: true,
      lastUserMessageAt: 2000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-2', role: 'user', content: 'mixed turn test', timestamp: 2000 },
        {
          id: 'assistant-mixed-1',
          role: 'assistant',
          content: [
            { type: 'thinking', thinking: 'I should search before answering.' },
            { type: 'text', text: 'Let me search for that.' },
            { type: 'toolCall', id: 'tool-2', name: 'web_search', input: { query: 'foo' } },
          ],
          stopReason: 'toolUse',
          timestamp: 2500,
        },
      ],
    });

    await useChatStore.getState().loadHistory(true);

    const state = useChatStore.getState();
    expect(state.sending).toBe(true);
    expect(state.activeRunId).toBe('run-mixed');
    expect(state.pendingFinal).toBe(true);
  });

  // Positive case: a real final reply (text/image, no pending tool) SHOULD
  // close the run when applyLoadedMessages observes it via history poll.
  it('closes the run when a final assistant reply (text, stopReason=endTurn) appears', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-3',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-3' }],
      messages: [
        { id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-final',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: true,
      lastUserMessageAt: 3000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-3', role: 'user', content: 'final reply test', timestamp: 3000 },
        {
          id: 'assistant-final-1',
          role: 'assistant',
          content: [
            { type: 'thinking', thinking: 'I have all the info.' },
            { type: 'text', text: 'Here is the answer.' },
          ],
          stopReason: 'endTurn',
          timestamp: 3500,
        },
      ],
    });

    await useChatStore.getState().loadHistory(true);

    const state = useChatStore.getState();
    expect(state.sending).toBe(false);
    expect(state.activeRunId).toBeNull();
    expect(state.pendingFinal).toBe(false);
  });

  // Cross-protocol coverage: Anthropic Messages API native shape (snake_case).
  // OpenClaw's gateway normally normalizes to camelCase, but some paths can
  // pass Anthropic responses through unchanged. `hasPendingToolUse` must still
  // detect the intermediate turn via `stop_reason: "tool_use"` plus
  // `content[].type === "tool_use"`.
  it('keeps the run open for Anthropic-native [thinking, tool_use] (snake_case stop_reason)', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-anthropic',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-anthropic' }],
      messages: [
        { id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-anthropic',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: 4000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-a', role: 'user', content: 'anthropic protocol', timestamp: 4000 },
        {
          id: 'assistant-anthropic-1',
          role: 'assistant',
          content: [
            { type: 'thinking', thinking: 'Should I use a tool?' },
            { type: 'tool_use', id: 'toolu_01', name: 'web_search', input: { query: 'foo' } },
          ],
          stop_reason: 'tool_use',
          timestamp: 4500,
        },
      ],
    });

    await useChatStore.getState().loadHistory(true);

    const state = useChatStore.getState();
    expect(state.sending).toBe(true);
    expect(state.activeRunId).toBe('run-anthropic');
  });

  // Cross-protocol coverage: OpenAI Chat Completions native shape. The
  // tool-call signal is the top-level `tool_calls` array on the message, with
  // no `stop_reason` / `stopReason` field (OpenAI uses `finish_reason` at the
  // choice level which doesn't reach the message object). `hasPendingToolUse`
  // must still flag this via the `tool_calls` array check.
  it('keeps the run open for OpenAI ChatCompletions message with tool_calls array', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-openai-cc',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-openai-cc' }],
      messages: [
        { id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-openai-cc',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: 5000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-occ', role: 'user', content: 'openai chat completions', timestamp: 5000 },
        {
          id: 'assistant-openai-cc-1',
          role: 'assistant',
          content: '',
          tool_calls: [
            {
              id: 'call_abc123',
              type: 'function',
              function: { name: 'web_search', arguments: '{"query":"foo"}' },
            },
          ],
          timestamp: 5500,
        },
      ],
    });

    await useChatStore.getState().loadHistory(true);

    const state = useChatStore.getState();
    expect(state.sending).toBe(true);
    expect(state.activeRunId).toBe('run-openai-cc');
  });

  // Cross-protocol coverage: OpenAI Chat Completions FINAL reply. No
  // tool_calls, plain text content. Must close the run normally.
  it('closes the run for OpenAI ChatCompletions plain-text final reply', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-openai-cc-final',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-openai-cc-final' }],
      messages: [
        { id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 },
      ],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-openai-cc-final',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: true,
      lastUserMessageAt: 6000,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-occf', role: 'user', content: 'openai final', timestamp: 6000 },
        {
          id: 'assistant-openai-cc-final-1',
          role: 'assistant',
          content: 'Here is the final answer.',
          timestamp: 6500,
        },
      ],
    });

    await useChatStore.getState().loadHistory(true);

    const state = useChatStore.getState();
    expect(state.sending).toBe(false);
    expect(state.activeRunId).toBeNull();
  });

  // Regression: the 90s safety timeout used to fire "No response received"
  // while the model was still working via tool chains. Gateway streaming can be
  // absent (WS drop, long tool execution) but chat.history still surfaces
  // intermediate assistant turns — those must count as progress.
  it('clears a stale no-response error when history poll shows tool progress', async () => {
    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-stuck',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-stuck' }],
      messages: [{ id: 'user-stuck', role: 'user', content: 'weather check' }],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: true,
      activeRunId: 'run-stuck-test',
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: Date.now(),
      pendingToolImages: [],
      error: 'No response received from the model. The provider may be unavailable or the API key may have insufficient quota. Please check your provider settings.',
      loading: false,
      thinkingLevel: null,
    });

    gatewayRpcMock.mockResolvedValueOnce({
      messages: [
        { id: 'user-stuck', role: 'user', content: 'weather check', timestamp: 1000 },
        {
          id: 'assistant-tool-stuck',
          role: 'assistant',
          content: [
            { type: 'thinking', thinking: 'Searching...' },
            { type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } },
          ],
          stopReason: 'toolUse',
          timestamp: 1500,
        },
      ],
    });

    await vi.advanceTimersByTimeAsync(1_000);
    await useChatStore.getState().loadHistory(true);

    expect(useChatStore.getState().error).toBeNull();
    expect(useChatStore.getState().sending).toBe(true);
    expect(useChatStore.getState().messages.some((msg) => msg.role === 'assistant')).toBe(true);
  });

  it('does not emit a false no-response error when history poll shows tool progress', async () => {
    let resolveSend: ((value: { runId: string }) => void) | undefined;
    gatewayRpcMock.mockImplementation((method: string) => {
      if (method === 'chat.send') {
        return new Promise((resolve) => {
          resolveSend = resolve;
        });
      }
      if (method === 'chat.history') {
        return {
          messages: [
            { id: 'user-stuck', role: 'user', content: 'weather check', timestamp: 1000 },
            {
              id: 'assistant-tool-stuck',
              role: 'assistant',
              content: [
                { type: 'thinking', thinking: 'Searching...' },
                { type: 'toolCall', id: 'tool-stuck', name: 'web_search', input: { q: 'weather' } },
              ],
              stopReason: 'toolUse',
              timestamp: 1500,
            },
          ],
        };
      }
      return { messages: [] };
    });

    const { useChatStore } = await import('@/stores/chat');
    useChatStore.setState({
      currentSessionKey: 'agent:main:session-stuck',
      currentAgentId: 'main',
      sessions: [{ key: 'agent:main:session-stuck' }],
      messages: [],
      sessionLabels: {},
      sessionLastActivity: {},
      sending: false,
      activeRunId: null,
      streamingText: '',
      streamingMessage: null,
      streamingTools: [],
      pendingFinal: false,
      lastUserMessageAt: null,
      pendingToolImages: [],
      error: null,
      loading: false,
      thinkingLevel: null,
    });

    const sendPromise = useChatStore.getState().sendMessage('weather check');

    await vi.advanceTimersByTimeAsync(7_000);
    await useChatStore.getState().loadHistory(true);
    await vi.advanceTimersByTimeAsync(100_000);

    expect(useChatStore.getState().error).toBeNull();
    expect(useChatStore.getState().sending).toBe(true);
    expect(useChatStore.getState().messages.some((msg) => msg.role === 'assistant')).toBe(true);

    resolveSend?.({ runId: 'run-stuck-test' });
    await sendPromise;
  });
});
