jest.mock('@/services/agent-service', () => ({
agentService: {
createAgent: jest.fn(),
getAgentsWithConversations: jest.fn(),
transformAgent: jest.fn(),
},
}))
jest.mock('@/services/session-service', () => ({
sessionService: {
transformConversationSummary: jest.fn(),
},
}))
jest.mock('../../cache', () => {
const actual = jest.requireActual('../../cache')
return {
...actual,
cacheGetAll: jest.fn(),
cacheSetAll: jest.fn(),
cacheDelete: jest.fn(),
}
})
jest.mock('@/app/config', () => ({
appConfig: {
app: {
useMockData: false,
},
},
}))
import { create } from 'zustand'
import { createAgentSlice, type AgentSlice } from '../agent-store'
import { createChatSlice, type ChatSlice } from '../chat-store'
import { createConnectionSlice, type ConnectionSlice } from '../connection-store'
import { createSettingsSlice, type SettingsSlice } from '../settings-store'
import { createUISlice, type UISlice } from '../ui-store'
import { AdapterType, SandboxType, AgentStatus } from '../../types'
import { agentService } from '@/services/agent-service'
import { sessionService } from '@/services/session-service'
import { cacheGetAll, cacheSetAll } from '../../cache'
import { appConfig } from '@/app/config'
type TestState = ChatSlice & AgentSlice & ConnectionSlice & SettingsSlice & UISlice
const useTestStore = create<TestState>()((...a) => ({
...createChatSlice(...a),
...createAgentSlice(...a),
...createConnectionSlice(...a),
...createSettingsSlice(...a),
...createUISlice(...a),
}))
const testAgent: ReturnType<typeof agentService.transformAgent> = {
id: 'test-agent',
name: 'Test Agent',
adapterType: AdapterType.OPENCODE,
sandboxType: SandboxType.DOCKER,
status: AgentStatus.RUNNING,
sandboxId: '',
defaultSessionId: '',
idleTimeoutSeconds: 300,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
const testConversation = {
id: 'conv-1',
title: 'Test Conversation',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
agentId: 'test-agent',
}
function mockTransformAgentImpl(item: any) {
return {
id: item.id,
name: item.name,
adapterType: AdapterType.OPENCODE,
sandboxType: SandboxType.DOCKER,
status: AgentStatus.RUNNING,
sandboxId: '',
defaultSessionId: '',
idleTimeoutSeconds: 300,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}
}
function setupTransformAgentMock() {
;(agentService.transformAgent as jest.Mock).mockImplementation(mockTransformAgentImpl)
}
function setupNetworkMocks(agents: any[], conversations: any[]) {
const enriched = agents.map(a => ({
id: a.id,
name: a.name,
conversations: conversations
.filter(c => c.agentId === a.id)
.map(c => ({
id: c.id,
title: c.title,
agentId: c.agentId,
})),
}))
;(agentService.getAgentsWithConversations as jest.Mock).mockResolvedValue(enriched)
setupTransformAgentMock()
;(sessionService.transformConversationSummary as jest.Mock).mockImplementation(
(summary: any, agentName: string) => ({
id: summary.id,
title: summary.title || '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
agentId: summary.agentId,
agentName,
})
)
}
describe('AgentSlice', () => {
beforeEach(() => {
jest.clearAllMocks()
;(appConfig as any).app.useMockData = false
useTestStore.setState({
agents: [],
currentAgentId: null,
agentStatus: {},
agentCreateFlag: 0,
isAgentsLoading: false,
conversations: [],
currentConversationId: null,
})
})
it('should have correct initial state', () => {
const state = useTestStore.getState()
expect(state.agents).toEqual([])
expect(state.currentAgentId).toBeNull()
expect(state.agentStatus).toEqual({})
expect(state.agentCreateFlag).toBe(0)
expect(state.isAgentsLoading).toBe(false)
})
it('should set current agent', () => {
useTestStore.getState().setCurrentAgent('agent-1')
expect(useTestStore.getState().currentAgentId).toBe('agent-1')
})
it('should trigger agent create', () => {
const initialFlag = useTestStore.getState().agentCreateFlag
useTestStore.getState().triggerAgentCreate()
expect(useTestStore.getState().agentCreateFlag).toBe(initialFlag + 1)
})
it('should add an agent', () => {
useTestStore.getState().addAgent(testAgent)
const state = useTestStore.getState()
expect(state.agents.length).toBe(1)
expect(state.agents[0].id).toBe('test-agent')
expect(state.agents[0].name).toBe('Test Agent')
})
it('should update an agent', () => {
useTestStore.getState().addAgent(testAgent)
const updated = { ...testAgent, name: 'Updated Agent', status: AgentStatus.RUNNING }
useTestStore.getState().updateAgent(updated)
const agent = useTestStore.getState().agents.find(a => a.id === 'test-agent')
expect(agent?.name).toBe('Updated Agent')
expect(agent?.status).toBe(AgentStatus.RUNNING)
})
it('should remove an agent', () => {
useTestStore.getState().addAgent(testAgent)
useTestStore.getState().removeAgent('test-agent')
expect(useTestStore.getState().agents.length).toBe(0)
})
it('should set agents array', () => {
useTestStore.getState().setAgents([testAgent])
expect(useTestStore.getState().agents.length).toBe(1)
expect(useTestStore.getState().agents[0].id).toBe('test-agent')
})
describe('removeAgent cross-slice behavior', () => {
it('should clean up conversations belonging to removed agent', () => {
useTestStore.getState().addAgent(testAgent)
useTestStore.setState({
conversations: [
{
id: 'conv-1',
title: 'Test',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
agentId: 'test-agent',
},
{
id: 'conv-2',
title: 'Other',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
agentId: 'other-agent',
},
],
currentConversationId: 'conv-1',
currentAgentId: 'test-agent',
})
useTestStore.getState().removeAgent('test-agent')
const state = useTestStore.getState()
expect(state.agents.length).toBe(0)
expect(state.conversations.length).toBe(1)
expect(state.conversations[0].agentId).toBe('other-agent')
expect(state.currentAgentId).toBeNull()
})
it('should clear currentConversationId if it belonged to removed agent', () => {
useTestStore.getState().addAgent(testAgent)
useTestStore.setState({
conversations: [
{
id: 'conv-1',
title: 'Test',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
agentId: 'test-agent',
},
],
currentConversationId: 'conv-1',
})
useTestStore.getState().removeAgent('test-agent')
expect(useTestStore.getState().currentConversationId).toBeNull()
})
it('should clean up agentStatus for removed agent', () => {
useTestStore.getState().addAgent(testAgent)
useTestStore.setState({
agentStatus: { 'test-agent': AgentStatus.RUNNING, 'other-agent': AgentStatus.PAUSED },
})
useTestStore.getState().removeAgent('test-agent')
const state = useTestStore.getState()
expect(state.agentStatus['test-agent']).toBeUndefined()
expect(state.agentStatus['other-agent']).toBe(AgentStatus.PAUSED)
})
it('should select next available non-deleted agent when current is removed', () => {
const otherAgent = { ...testAgent, id: 'other-agent', name: 'Other Agent' }
const deletedAgent = {
...testAgent,
id: 'soft-deleted',
name: 'Soft Deleted',
status: AgentStatus.DELETED,
}
useTestStore.getState().setAgents([otherAgent, deletedAgent, testAgent])
useTestStore.setState({ currentAgentId: 'test-agent' })
useTestStore.getState().removeAgent('test-agent')
const state = useTestStore.getState()
expect(state.currentAgentId).toBe('other-agent')
expect(state.agents.length).toBe(2)
})
})
describe('initializeAgent', () => {
const createConfig = {
name: 'New Agent',
description: 'A test agent',
sandboxType: SandboxType.DOCKER,
adapterType: AdapterType.OPENCODE,
idleTimeoutSeconds: 300,
}
it('should create agent via service in real mode', async () => {
const createdAgent = { ...testAgent, id: 'created-agent', name: 'New Agent' }
;(agentService.createAgent as jest.Mock).mockResolvedValue(createdAgent)
const result = await useTestStore.getState().initializeAgent(createConfig)
expect(agentService.createAgent).toHaveBeenCalledWith(createConfig)
expect(result).toEqual(createdAgent)
const state = useTestStore.getState()
expect(state.agents).toContainEqual(createdAgent)
expect(state.currentAgentId).toBe('created-agent')
})
it('should create mock agent when useMockData is true', async () => {
;(appConfig as any).app.useMockData = true
const result = await useTestStore.getState().initializeAgent(createConfig)
expect(agentService.createAgent).not.toHaveBeenCalled()
expect(result.id).toBeTruthy()
expect(result.name).toBe('New Agent')
expect(result.status).toBe(AgentStatus.RUNNING)
const state = useTestStore.getState()
expect(state.agents).toContainEqual(result)
expect(state.currentAgentId).toBe(result.id)
})
it('should use defaults for missing config fields in mock mode', async () => {
;(appConfig as any).app.useMockData = true
const result = await useTestStore.getState().initializeAgent({ name: 'Minimal' } as any)
expect(result.name).toBe('Minimal')
expect(result.adapterType).toBe(AdapterType.OPENCODE)
expect(result.sandboxType).toBe(SandboxType.DOCKER)
expect(result.idleTimeoutSeconds).toBe(300)
})
})
describe('fetchAgentsWithConversations', () => {
it('should populate store on network success', async () => {
;(cacheGetAll as jest.Mock).mockReturnValue(null)
setupNetworkMocks([{ id: 'agent-1', name: 'Agent 1' }], [])
const result = await useTestStore.getState().fetchAgentsWithConversations()
expect(result).toBeUndefined()
expect(agentService.getAgentsWithConversations).toHaveBeenCalledTimes(1)
const state = useTestStore.getState()
expect(state.agents.length).toBe(1)
expect(state.agents[0].name).toBe('Agent 1')
expect(state.isAgentsLoading).toBe(false)
})
it('should apply cached data before network completes', async () => {
const cachedAgent = { ...testAgent, id: 'cached-agent', name: 'Cached Agent' }
;(cacheGetAll as jest.Mock).mockReturnValue({
agents: [cachedAgent],
conversations: [],
sessionAgentNames: [],
})
let resolveNetwork: (value: any) => void
const deferred = new Promise<any[]>(resolve => {
resolveNetwork = resolve
})
;(agentService.getAgentsWithConversations as jest.Mock).mockReturnValue(deferred)
setupTransformAgentMock()
await useTestStore.getState().fetchAgentsWithConversations()
const stateBeforeNetwork = useTestStore.getState()
expect(stateBeforeNetwork.agents).toContainEqual(cachedAgent)
resolveNetwork!([{ id: 'agent-1', name: 'Agent 1', conversations: [] }])
await new Promise(r => setTimeout(r, 10))
const stateAfterNetwork = useTestStore.getState()
expect(stateAfterNetwork.agents[0].name).toBe('Agent 1')
})
it('should patch agentName onto existing store conversations using sessionAgentNames', async () => {
useTestStore.setState({
conversations: [{ ...testConversation, sessionId: 'sess-1', agentName: undefined }],
})
;(cacheGetAll as jest.Mock).mockReturnValue({
agents: [testAgent],
conversations: [],
sessionAgentNames: [['sess-1', testAgent.name]],
})
let resolveNetwork: (value: any) => void
const deferred = new Promise<any[]>(resolve => {
resolveNetwork = resolve
})
;(agentService.getAgentsWithConversations as jest.Mock).mockReturnValue(deferred)
setupTransformAgentMock()
await useTestStore.getState().fetchAgentsWithConversations()
const state = useTestStore.getState()
expect(state.conversations.length).toBe(1)
const patched = state.conversations.find((c: any) => c.id === 'conv-1')
expect(patched?.agentName).toBe(testAgent.name)
resolveNetwork!([{ id: 'agent-1', name: 'Agent 1', conversations: [] }])
await new Promise(r => setTimeout(r, 10))
})
it('should handle network error gracefully', async () => {
;(cacheGetAll as jest.Mock).mockReturnValue(null)
;(agentService.getAgentsWithConversations as jest.Mock).mockRejectedValue(
new Error('Network error')
)
await useTestStore.getState().fetchAgentsWithConversations()
const state = useTestStore.getState()
expect(state.isAgentsLoading).toBe(false)
expect(state.agents).toEqual([])
})
it('should deduplicate concurrent calls', async () => {
;(cacheGetAll as jest.Mock).mockReturnValue(null)
let resolveNetwork: (value: any) => void
const deferred = new Promise<any[]>(resolve => {
resolveNetwork = resolve
})
;(agentService.getAgentsWithConversations as jest.Mock).mockReturnValue(deferred)
setupTransformAgentMock()
;(sessionService.transformConversationSummary as jest.Mock).mockReturnValue({
id: 'conv-1',
title: '',
messages: [],
createdAt: new Date(),
updatedAt: new Date(),
agentId: 'agent-1',
agentName: 'Agent 1',
})
const store = useTestStore.getState()
const call1 = store.fetchAgentsWithConversations()
const call2 = store.fetchAgentsWithConversations()
resolveNetwork!([{ id: 'agent-1', name: 'Agent 1', conversations: [] }])
await Promise.all([call1, call2])
expect(agentService.getAgentsWithConversations).toHaveBeenCalledTimes(1)
const finalState = useTestStore.getState()
expect(finalState.agents.length).toBe(1)
expect(finalState.isAgentsLoading).toBe(false)
})
it('should keep existing conversations after network refresh', async () => {
useTestStore.setState({
conversations: [
{
...testConversation,
id: 'existing-conv',
agentId: 'agent-1',
sessionId: 'sess-existing',
},
],
})
;(cacheGetAll as jest.Mock).mockReturnValue(null)
setupNetworkMocks(
[{ id: 'agent-1', name: 'Agent 1' }],
[{ id: 'new-conv', title: 'New', agentId: 'agent-1' }]
)
await useTestStore.getState().fetchAgentsWithConversations()
const state = useTestStore.getState()
expect(state.conversations.length).toBeGreaterThanOrEqual(1)
const existing = state.conversations.find((c: any) => c.id === 'existing-conv')
expect(existing).toBeTruthy()
})
it('should skip cached conversations whose sessionId already exists in store', async () => {
useTestStore.setState({
conversations: [
{
...testConversation,
id: 'store-conv',
sessionId: 'sess-dup',
agentId: 'test-agent',
},
],
})
const cachedConv = {
...testConversation,
id: 'cached-conv',
sessionId: 'sess-dup',
agentId: 'test-agent',
}
;(cacheGetAll as jest.Mock).mockReturnValue({
agents: [testAgent],
conversations: [cachedConv],
sessionAgentNames: [],
})
let resolveNetwork: (value: any) => void
const deferred = new Promise<any[]>(resolve => {
resolveNetwork = resolve
})
;(agentService.getAgentsWithConversations as jest.Mock).mockReturnValue(deferred)
setupTransformAgentMock()
await useTestStore.getState().fetchAgentsWithConversations()
const state = useTestStore.getState()
const ids = state.conversations.map((c: any) => c.id)
expect(ids).not.toContain('cached-conv')
expect(ids).toContain('store-conv')
resolveNetwork!([{ id: 'test-agent', name: 'Test Agent', conversations: [] }])
await new Promise(r => setTimeout(r, 10))
})
})
})